﻿using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using System.Xml.Linq;
using Ionic.Zip;
using System.Diagnostics;

namespace eBdb.EpubReader
{
    public class Epub
    {

        #region Properties
        //Note: Mandatory fields defined by OPF standard
        public string UUID { get; private set; }
        public List<string> ID { get; private set; }
        public List<string> Title { get; private set; }
        public List<string> Language { get; private set; }

        //Note: Not mandatory fields by OPF standard
        public List<string> Creator { get; private set; }
        public List<string> Description { get; private set; }
        public List<DateData> Date { get; private set; }
        public List<string> Publisher { get; private set; }
        public List<string> Contributer { get; private set; }
        public List<string> Type { get; private set; }
        public List<string> Format { get; private set; }
        public List<string> Subject { get; private set; }
        public List<string> Source { get; private set; }
        public List<string> Relation { get; private set; }
        public List<string> Coverage { get; private set; }
        public List<string> Rights { get; private set; }

        public OrderedDictionary Content { get; private set; }
        public OrderedDictionary ExtendedData { get; private set; }
        public List<NavPoint> TOC { get; private set; }

        private readonly ZipFile _EpubFile;
        private readonly string _ContentOpfPath;
        private string _TocFileName;
        #endregion

        #region Constructor
        public Epub(string ePubPath)
        {
            ID = new List<string>();
            Title = new List<string>();
            Language = new List<string>();

            Creator = new List<string>();
            Description = new List<string>();
            Date = new List<DateData>();
            Publisher = new List<string>();
            Contributer = new List<string>();
            Type = new List<string>();
            Format = new List<string>();
            Subject = new List<string>();
            Source = new List<string>();
            Relation = new List<string>();
            Coverage = new List<string>();
            Rights = new List<string>();

            Content = new OrderedDictionary();
            ExtendedData = new OrderedDictionary();
            TOC = new List<NavPoint>();

            if (File.Exists(ePubPath)) _EpubFile = ZipFile.Read(ePubPath);
            else throw new FileNotFoundException();

            string opfFilePath = GetOpfFilePath(_EpubFile);
            if (string.IsNullOrEmpty(opfFilePath)) throw new Exception("Invalid epub file.");

            Match m = Regex.Match(opfFilePath, @"^.*/", Utils.REO_c);
            _ContentOpfPath = m.Success ? m.Value : "";

            LoadEpubMetaDataFromOpfFile(opfFilePath);
            if (_TocFileName != null) LoadTableOfContents();
        }
        #endregion

        #region Public Functions
        public string GetContentAsPlainText()
        {
            StringBuilder builder = new StringBuilder();
            for (int i = 0, y = Content.Count; i < y; ++i) builder.Append(((ContentData)Content[i]).GetContentAsPlainText());
            return builder.ToString();
        }

        //Developer: Brian Kenney
        //Date: 8/2/2012
        //Change: Improved speed of rendering page by moving functions
        //NormalizeRefs & EmbedImages to the section where we process the body
        /// <summary>
        /// 取得小说文件。
        /// </summary>
        /// <returns></returns>
        public string GetContentAsHtml()
        {
            if (Content.Count == 1) return NormalizeRefs(((ContentData)Content[0]).Content);
            StringBuilder body = new StringBuilder();
            //body.Append(GetTOCData());
            for (int i = 0, y = Content.Count; i < y; ++i)
            {
                var c = (ContentData)Content[i];
                Match m = Regex.Match(c.Content, @"<body[^>]*>(?<body>.+)</body>", Utils.REO_csi);
                if (m.Success)
                {
                    //add link to top of page so they can be found by the table of contents
                    //then update links within body
                    string b = NormalizeRefs("<a id=\"" + c.FileName.Replace('.', '-') + "\"/>" + m.Groups["body"].Value);
                    //string b = "<a id=\"" + c.FileName.Replace('.', '-') + "\"/>" + m.Groups["body"].Value;

                    //embed base64 images & append
                    b = EmbedImages(b);
                    body.Append(b);
                }
            }

            string headPart = "";
            Match match = Regex.Match(((ContentData)Content[Content.Count - 1]).Content, @"<head[^>]*>(?<head>.+?)</head>", Utils.REO_csi);
            if (match.Success) headPart = Regex.Replace(match.Groups["head"].Value, @"<title[^>]*>.+?</title>", "", Utils.REO_csi);
            //Developer: Brian Kenney
            //Date: 8/5/2012
            //Change: added content type to header
            //Details: Special characters were not rendering correctly.  By telling the browser to use UTF-8 character set,
            //characters will display correctly.
            headPart += "<meta http-equiv=\"Content-Type\" content=\"text/html;charset=utf-8\" />";

            //Developer: Brian Kenney
            //Date: 7/30/2012
            //Change: embed core.css style sheet 
            //Details:
            //replace linked style sheet with contents of core style sheet
            if (headPart.Contains("<link rel=\"stylesheet\" href=\"core.css\" type=\"text/css\"/>"))
            {
                for (int i = 0; i < ExtendedData.Count; i++)
                {
                    var ed = (ExtendedData)ExtendedData[i];
                    if (ed.MimeType == "text/css")
                    {
                        var css = "<style type=\"text/css\">" + ed.Content + "</style>";
                        headPart = headPart.Replace("<link rel=\"stylesheet\" href=\"core.css\" type=\"text/css\"/>", css);
                        break;
                    }
                }
            }

            string bodyTag = "<body>";
            match = Regex.Match(((ContentData)Content[Content.Count - 1]).Content, @"<body[^>]*>", Utils.REO_ci);
            if (match.Success) bodyTag = match.Value;

            //Debug.Write(body.ToString().Length.ToString());
            return string.Format(_HtmlTemplate, string.Join(", ", Creator) + " - " + Title[0], headPart.Trim(), bodyTag, body.ToString());

            //
            //for reasons of speed, EmbedImages & NormalizeRefs 
            //function calls have been moved to the processing of body section above
            //
            //embed base64 images
            //var strBody = EmbedImages(body.ToString());
            //return NormalizeRefs(string.Format(_HtmlTemplate, string.Join(", ", Creator) + " - " + Title[0], headPart.Trim(), bodyTag, strBody));//body));
        }
        #endregion

        #region Private Functions
        //Developer: Brian Kenney
        //Date: 7/30/2012
        //Change: added function to insert images
        //Details:
        //take the images loaded into ExtendedData in function LoadManifestSectionFromOpfFile()
        //find their corresponding place in the html and replace with base64 image
        //
        //Developer: Brian Kenney
        //Date: 8/2/2012
        //Change: We nolonger transverse the entire ExtendedData collection instead
        //grab only those image tags embedded in the html string and match them against the 
        //ExtendedData type and retrieve the Base64 string for the image
        private string EmbedImages(string html)
        {
            //retrieve all image tags
            var imgTags = Regex.Matches(html, @"(?<prefix><\w+[^>]*?(src)="")(?<src>[^""]*)(?<suffix>""[^>]*>)");

            //retrieve all keys and convert to string
            var allKeys = ExtendedData.Keys.OfType<string>();

            for (int i = 0; i < imgTags.Count; i++)
            {
                //feed m[i] into string var so we don't have to keep performing a lookup
                var imgTag = imgTags[i].ToString();
                if (!string.IsNullOrEmpty(imgTag))
                {
                    //select all keys from allKeys list
                    //where scr part of image tag contains key
                    var keys = from k in allKeys
                               where imgTag.Contains(k)
                               select k;

                    foreach (var key in keys)
                    {
                        //retrieve ExtendedData type matching our key
                        var ed = ((ExtendedData)ExtendedData[key]);

                        //if a corresponding entry was found 
                        //replace image with base64 encoded image
                        if (ed != null)
                        {
                            html = html.Replace(ed.FileName, "data:" + ed.MimeType + ";base64," + ed.Content);
                        }
                    }

                }
            }
            return html;


            //for (int i = 0; i < ExtendedData.Count; i++)
            //{
            //    var ed = (ExtendedData)ExtendedData[i];
            //    if (ed.MimeType.Contains("image"))
            //    {
            //        html = html.Replace(ed.FileName, "data:" + ed.MimeType + ";base64," + ed.Content);
            //    }
            //}
        }

        //
        //Developer: Brian Kenney
        //Date: 7/29/2012
        //Change: remove namespace prefix
        //Details: 
        //some opf files come with the namespace prefix of odfc, so remvoe the prefix before processing
        //
        private static string GetOpfFilePath(ZipFile epubFile)
        {
            string tmpXMLStream;
            ZipEntry zipEntry = epubFile.Entries.FirstOrDefault(e => e.FileName.Equals(@"meta-inf/container.xml", StringComparison.InvariantCultureIgnoreCase));
            if (zipEntry != null)
            {
                XElement containerXml;
                using (MemoryStream memoryStream = new MemoryStream())
                {
                    zipEntry.Extract(memoryStream);
                    memoryStream.Position = 0;

                    containerXml = XElement.Load(memoryStream);

                    //get stream just in case we have a namespace prefix
                    memoryStream.Position = 0;
                    var sr = new System.IO.StreamReader(memoryStream);
                    tmpXMLStream = sr.ReadToEnd();
                }

                XNamespace xNamespace = containerXml.Attribute("xmlns") != null ? containerXml.Attribute("xmlns").Value : XNamespace.None;
                if (xNamespace != XNamespace.None)
                {
                    return containerXml.Descendants(xNamespace + "rootfile").FirstOrDefault(p => p.Attribute("media-type") != null && p.Attribute("media-type").Value.Equals("application/oebps-package+xml", StringComparison.InvariantCultureIgnoreCase)).Attribute("full-path").Value;
                }
                else
                {
                    //remove odfc namespace prefix and process
                    XDocument xDocument = XDocument.Parse(tmpXMLStream);
                    xDocument.Root.Add(new XAttribute("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"));
                    xDocument.Root.Attributes(XNamespace.Xmlns + "odfc").Remove();
                    containerXml = XElement.Parse(xDocument.ToString());
                    xNamespace = containerXml.Attribute("xmlns") != null ? containerXml.Attribute("xmlns").Value : XNamespace.None;
                    if (xNamespace != XNamespace.None)
                    {
                        return containerXml.Descendants(xNamespace + "rootfile").FirstOrDefault(p => p.Attribute("media-type") != null && p.Attribute("media-type").Value.Equals("application/oebps-package+xml", StringComparison.InvariantCultureIgnoreCase)).Attribute("full-path").Value;
                    }
                }
            }
            return null;
        }

        private void LoadEpubMetaDataFromOpfFile(string opfFilePath)
        {
            ZipEntry zipEntry = _EpubFile.Entries.FirstOrDefault(e => e.FileName.Equals(opfFilePath, StringComparison.InvariantCultureIgnoreCase));
            if (zipEntry == null) throw new Exception("Invalid epub file.");

            XElement contentOpf;
            using (MemoryStream memoryStream = new MemoryStream())
            {
                zipEntry.Extract(memoryStream);
                memoryStream.Position = 0;
                contentOpf = XElement.Load(memoryStream);
            }

            XNamespace xNamespace = contentOpf.Attribute("xmlns") != null ? contentOpf.Attribute("xmlns").Value : XNamespace.None;

            string uniqueIdentifier = contentOpf.Attribute("unique-identifier").Value;
            UUID = contentOpf.Elements(xNamespace + "metadata").Elements().FirstOrDefault(e => e.Name.LocalName == "identifier" && e.Attribute("id").Value == uniqueIdentifier).Value;
            foreach (var metadataElement in contentOpf.Elements(xNamespace + "metadata").Elements().Where(e => e.Value.Trim() != string.Empty))
            {
                switch (metadataElement.Name.LocalName)
                {
                    case "title": Title.Add(metadataElement.Value); break;
                    case "creator": Creator.Add(metadataElement.Value); break;
                    case "date":
                        var attribute = metadataElement.Attributes().FirstOrDefault(a => a.Name.LocalName == "event");
                        if (attribute != null) Date.Add(new DateData(attribute.Value, metadataElement.Value));
                        break;
                    case "publisher": Publisher.Add(metadataElement.Value); break;
                    case "subject": Subject.Add(metadataElement.Value); break;
                    case "source": Source.Add(metadataElement.Value); break;
                    case "rights": Rights.Add(metadataElement.Value); break;
                    case "description": Description.Add(metadataElement.Value); break;
                    case "contributor": Contributer.Add(metadataElement.Value); break;
                    case "type": Type.Add(metadataElement.Value); break;
                    case "format": Format.Add(metadataElement.Value); break;
                    case "identifier": ID.Add(metadataElement.Value); break;
                    case "language": Language.Add(metadataElement.Value); break;
                    case "relation": Relation.Add(metadataElement.Value); break;
                    case "coverage": Coverage.Add(metadataElement.Value); break;
                }
            }

            LoadManifestSectionFromOpfFile(contentOpf, xNamespace);
        }


        private void LoadManifestSectionFromOpfFile(XElement contentOpf, XNamespace xNamespace)
        {
            //with the content.opf file
            //grab the idref from the spine element and
            //find a match corresponding to the elements listed under the manifest section
            HashSet<string> alreadyProcessedFiles = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
            foreach (var spinElement in contentOpf.Elements(xNamespace + "spine").Elements())
            {
                string fileName = HttpUtility.UrlDecode(contentOpf.Elements(xNamespace + "manifest").Elements().FirstOrDefault(e => e.Attribute("id").Value == spinElement.Attribute("idref").Value).Attribute("href").Value);
                ZipEntry contentZipEntry = _EpubFile.Entries.FirstOrDefault(e => e.FileName.Equals(_ContentOpfPath + fileName, StringComparison.InvariantCultureIgnoreCase));
                if (contentZipEntry == null) throw new Exception("Invalid epub file.");
                //
                //Developer: Brian Kenney
                //Date: 7/29/2012
                //Change: duplicate dictionary key
                //Details: ran into a mis-packaged epub file added key check 
                //to ensure that we don't crash should someone mispackage
                //
                //check to see if fileName has already been added to Content dictionary
                if (!Content.Contains(fileName)) Content.Add(fileName, new ContentData(fileName, contentZipEntry));
                if (!alreadyProcessedFiles.Contains(spinElement.Attribute("idref").Value)) alreadyProcessedFiles.Add(spinElement.Attribute("idref").Value);
            }

            //grab the rest of the elements not already processed in the manifest
            IEnumerable<XElement> manifestElements = contentOpf.Elements(xNamespace + "manifest").Elements().Where(e => !alreadyProcessedFiles.Contains(e.Attribute("id").Value));
            foreach (var manifestElement in manifestElements)
            {
                string fileName = HttpUtility.UrlDecode(manifestElement.Attribute("href").Value);
                ZipEntry extendedZipEntry = _EpubFile.Entries.FirstOrDefault(e => e.FileName.Equals(_ContentOpfPath + fileName, StringComparison.InvariantCultureIgnoreCase));
                if (extendedZipEntry == null) continue;
                //check to see if fileName has already been added to Extended dictionary
                if (!ExtendedData.Contains(fileName)) ExtendedData.Add(fileName, new ExtendedData(fileName, manifestElement.Attribute("media-type").Value, extendedZipEntry));
                if (string.Equals(manifestElement.Attribute("media-type").Value, "application/x-dtbncx+xml", StringComparison.InvariantCultureIgnoreCase)) _TocFileName = HttpUtility.UrlDecode(manifestElement.Attribute("href").Value);
            }
        }

        //Developer: Brian Kenny
        //Date: 7/30/2012
        //Changes: changed links to point to correct location
        //Details:
        //links internal to the epub now point to a-links
        //links to the external world open in a new window
        private string NormalizeRefs(string text)
        {
            if (text == null) return null;
            StringBuilder builder = new StringBuilder(text);
            //return Regex.Replace(text, @"(?<prefix><\w+[^>]*?(href|src)="")(?<href>[^""]*)(?<suffix>""[^>]*>)",
            //                     match =>
            //                     match.Groups["href"].Value.StartsWith("http://", StringComparison.InvariantCultureIgnoreCase) || match.Groups["href"].Value.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase) || match.Groups["href"].Value.StartsWith("#", StringComparison.InvariantCultureIgnoreCase) ?
            //                        match.Value :
            //                        match.Groups["prefix"].Value + "ePubReader/extData.aspx?filePath=" +
            //                          HttpUtility.UrlEncode(HttpUtility.UrlDecode(match.Groups["href"].Value)) + "&epub=" +
            //                          HttpUtility.UrlEncode(Path.GetFileName(_EpubFile.Name)) + match.Groups["suffix"].Value, Utils.REO_ci);

            var m = Regex.Matches(text, @"(?<prefix><\w+[^>]*?(href)="")(?<href>[^""]*)(?<suffix>""[^>]*>)");

            for (int i = 0; i < m.Count; i++)
            {
                var originalval = m[i].Value;
                if (!originalval.Contains("http://") && !originalval.Contains("mailto:") && !originalval.Contains("ftp://")) //don't touch external & mailto links
                {
                    if (originalval.Contains("#"))  //retrieve a-link portion
                    {
                        string[] l1 = originalval.Split('#');
                        string newval = "<a href=\"#" + l1[1];
                        //text = text.Replace(originalval, newval);
                        builder.Replace(originalval, newval);
                    }
                    else
                    {
                        //internal link
                        string newval = originalval.Insert(originalval.IndexOf("href=") + 6, "#");
                        newval = newval.Replace('.', '-');
                        //text = text.Replace(originalval, newval);
                        builder.Replace(originalval, newval);
                    }
                }
                else
                {
                    //open links eternal to the document in their own window
                    if (originalval.Contains("http://") || originalval.Contains("ftp://"))
                    {
                        string newval = originalval.Replace(">", " target=\"_blank\" >");
                        //text = text.Replace(originalval, newval);
                        builder.Replace(originalval, newval);
                    }
                }
            }
            return builder.ToString();
        }

        private void LoadTableOfContents()
        {
            ExtendedData extendedData = ExtendedData[_TocFileName] as ExtendedData;
            if (extendedData == null) return;

            XElement xElement = XElement.Parse(extendedData.Content);
            XNamespace xNamespace = xElement.Attribute("xmlns") != null ? xElement.Attribute("xmlns").Value : XNamespace.None;

            //Developer: Brian Kenney
            //Date: 7/29/2012
            //
            //some files have the namespace prefix of ncx
            //if it does then then xNamespace will evaluate to None
            if (xNamespace != XNamespace.None)
            {
                TOC = GetNavigationChildren(xElement.Element(xNamespace + "navMap").Elements(xNamespace + "navPoint"), xNamespace);
            }
            else
            {
                //Change: Brian Kenney
                //Date: 7/29/2012
                //Change: duplicate dictionary key
                //Details: the file may have an ncx namespace prefix
                //romeve the ncx prefix itself
                XDocument xDocument = XDocument.Parse(@extendedData.Content);
                xDocument.Root.Add(new XAttribute("xmlns", "http://www.daisy.org/z3986/2005/ncx/"));
                xDocument.Root.Attributes(XNamespace.Xmlns + "ncx").Remove();
                xElement = XElement.Parse(xDocument.ToString());
                xNamespace = xElement.Attribute("xmlns") != null ? xElement.Attribute("xmlns").Value : XNamespace.None;
                if (xNamespace != XNamespace.None)
                {
                    TOC = GetNavigationChildren(xElement.Element(xNamespace + "navMap").Elements(xNamespace + "navPoint"), xNamespace);
                }
            }
        }

        private List<NavPoint> GetNavigationChildren(IEnumerable<XElement> elements, XNamespace nameSpace)
        {
            List<NavPoint> navigationPoints = new List<NavPoint>(elements.Count());
            if (!elements.Any()) return navigationPoints;
            navigationPoints.AddRange(elements.Select(navPoint =>
                new NavPoint(navPoint.Attribute("id").Value,
                            navPoint.Element(nameSpace + "navLabel").Element(nameSpace + "text").Value,
                            HttpUtility.UrlDecode(navPoint.Element(nameSpace + "content").Attribute("src").Value),
                            int.Parse(navPoint.Attribute("playOrder").Value), Content[NormalizeFileName(HttpUtility.UrlDecode(navPoint.Element(nameSpace + "content").Attribute("src").Value))] as ContentData,
                            GetNavigationChildren(navPoint.Elements(nameSpace + "navPoint"),
                            nameSpace))));
            return navigationPoints;
        }

        private static string NormalizeFileName(string fileName)
        {
            return fileName == null ? null : Regex.Replace(fileName, @"\#.*$", "", Utils.REO_c);
        }

        /*
         * Support of TOC a little bit complicated. NavPoint can point to new page or to some place within existing page. It requires html replacement in existing data.
         * private string GetTOCData(List<NavPoint> navPoints) {
            if (navPoints.Count == 0) return "";
            StringBuilder builder = new StringBuilder();
            builder.Append("<ol>");
            foreach (var navPoint in navPoints) {
                builder.AppendFormat("<li><a href='{0}'>{1}</a></li>", navPoint.Source)
            }
        }*/

        private const string _HtmlTemplate = @"<!DOCTYPE html
			  PUBLIC ""-//W3C//DTD XHTML 1.1//EN"" ""http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"">
			<html>
			   <head>
				  <title>{0}</title>
				  {1}
			   </head>
			   {2}
				  {3}
			   </body>
			</html>";
        #endregion
    }
}
